「爬取外部資料的邏輯,不就是發個請求、解析回應嗎?能有多複雜?」
今天要看的這個 Crawler 類別,全部加起來只有 38 行,論長度稱不上複雜。但這 38 行裡塞進的技巧密度相當高:分頁迭代不預先展開、HTTP client 不寫死實作、重試邏輯用套件疊加而不是自己刻。今天要把這幾個技巧一個一個拆開看。
Generator 怎麼處理「不知道總共有幾頁」的分頁 API這個 Crawler 要爬取的外部 API 是分頁式的——每次請求回傳一頁資料,回應裡帶著目前頁碼跟總頁數。核心方法用 do...while 搭配 Generator:
public function fetchNews(?Carbon $date = null, $perPage = 30): Generator
{
$page = 1;
do {
$response = $this->client->sendRequest(/* 組出帶頁碼的請求 */);
$data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
if (! isset($data['content'])) {
break;
}
foreach ($data['content'] as $row) {
yield $row;
}
$page++;
} while ((int) $data['pageNumber'] < (int) $data['totalPages']);
}
這裡用 yield 一筆一筆吐出資料,而不是把所有分頁資料先收集進一個陣列再整批回傳。跟 Day 13 看到的 dateRange() 是同一個原則:呼叫端要一筆、才真的去要一筆,不會因為總筆數多就佔用大量記憶體。這在爬取分頁 API 時特別重要——分頁數量不是自己可以控制的,外部系統回傳幾頁,就要迭代幾次,沒有上限保證。
建構子的預設值特別值得注意:
public function __construct(
?ClientInterface $client = null,
?RequestFactoryInterface $requestFactory = null,
?string $baseUrl = null
) {
$this->client = new PluginClient($client ?? Psr18ClientDiscovery::find(), [
new RetryPlugin(['retries' => 3]),
]);
$this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
// ...
}
如果呼叫端沒有明確傳入 ClientInterface 實例,就用 Psr18ClientDiscovery::find() 自動探索當前專案裡裝了哪個 PSR-18 相容的 HTTP client 套件,自動找出來使用。這是「不寫死實作」原則的延伸版本——連「要用哪個 HTTP client」這件事,都不寫死在程式碼裡,而是讓套件自己去偵測環境裡實際裝了什麼。這種寫法常見於設計成要被廣泛複用的函式庫,不特別綁定某個框架或某個 HTTP client 品牌。
PluginClient 把原始的 HTTP client 包了一層,加上 RetryPlugin(['retries' => 3])——這代表這支 Crawler 送出的每一個請求,如果失敗(連線異常、逾時等),會自動重試最多 3 次,完全不需要自己寫一段 for 迴圈手動處理重試邏輯。
這是很值得記住的一個習慣:重試、逾時、快取這類橫切關注點,通常已經有現成的 middleware/plugin 可以疊加使用,不需要自己刻一套。自己刻的重試邏輯,容易漏掉一些邊角情況(例如指數退避、哪些錯誤該重試哪些不該),用套件提供的 Plugin,通常已經考慮過這些細節。
這支 Crawler 的預設 baseUrl,在真實程式碼裡是一個寫死的內部網路位址。這是一個值得停下來提醒的地方:任何程式碼範例裡出現的網址、IP,如果是內部系統的真實位址,公開分享程式碼片段時一定要換成範例用的假位址(例如 http://10.0.0.1:3000 這種明顯不是真實可連線位址的範例值),這不是這篇文章特有的顧慮,是任何團隊在公開分享內部系統程式碼時都該養成的習慣。
❌ 自己手動寫一段 retry 迴圈
$attempts = 0;
while ($attempts < 3) {
try {
return $client->sendRequest($request);
} catch (ClientExceptionInterface $e) {
$attempts++;
if ($attempts >= 3) throw $e;
}
}
// 容易漏掉指數退避、哪些例外該重試等細節
✅ 用 PluginClient 疊加 RetryPlugin
$client = new PluginClient($baseClient, [
new RetryPlugin(['retries' => 3]),
]);
$client->sendRequest($request);
// 重試邏輯交給套件處理,呼叫端程式碼保持乾淨
你的專案裡有沒有自己手動寫的重試/逾時邏輯?回想一下,有沒有現成的套件/middleware 可以取代它,讓這段橫切關注點不用自己維護?
yield 逐筆吐出資料,不用預先知道總頁數也能安全迭代PluginClient + RetryPlugin 疊加,不用自己刻一段 retry 迴圈明天要進入資安相關的主題:一次真實發生過的檔案上傳攻擊事件,事後才補上的 webshell 防護,是怎麼一回事。